Coverage Report

Created: 2026-04-26 08:04

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\xtask\src\social_preview.rs
Line
Count
Source
1
//! Social preview image generation.
2
//!
3
//! Orchestrates `docker run` against the pinned Playwright image to render
4
//! `templates/social-preview.html` into a 1280×640 PNG with live data
5
//! fetched from the GitHub API. The Rust side is a thin shell: all HTTP,
6
//! template substitution, and screenshotting live in
7
//! `xtask/social-preview/generate.mjs`, which runs inside the container.
8
//!
9
//! The host only needs Rust, Cargo, and Docker. No host-side Node.js, npm,
10
//! or Playwright installation is required.
11
12
use std::path::{Path, PathBuf};
13
14
use anyhow::{bail, Context, Result};
15
16
/// Pinned Playwright Docker image tag.
17
///
18
/// The numeric portion (e.g. `v1.59.1`) must match `@playwright/test` in
19
/// `xtask/social-preview/package.json`. Playwright refuses to run when
20
/// these versions diverge, so bump both in the same commit. See
21
/// `xtask/social-preview/README.md` for details.
22
const PLAYWRIGHT_IMAGE: &str = "mcr.microsoft.com/playwright:v1.59.1-noble";
23
24
/// Default output path for the generated PNG, relative to the workspace
25
/// root. Lives under `target/` so it shares Cargo's build-artifact
26
/// directory and inherits its `.gitignore` entry.
27
const DEFAULT_OUT: &str = "target/social-preview/social-preview.png";
28
29
/// Container-side mount point for the workspace.
30
const CONTAINER_WORKSPACE: &str = "/workspace";
31
32
/// All side-effecting operations required by this module.
33
///
34
/// Implement with mocks in tests to achieve zero docker, filesystem,
35
/// process, and network side-effects.
36
pub trait SocialPreviewSystem {
37
    /// Return the absolute path to the workspace root (parent of `xtask/`).
38
    ///
39
    /// # Errors
40
    ///
41
    /// Returns an error if the workspace root cannot be resolved.
42
    fn workspace_root(&self) -> Result<PathBuf>;
43
44
    /// Read an environment variable, returning `None` when unset or empty.
45
    fn env_var(&self, key: &str) -> Option<String>;
46
47
    /// Ensure the parent directory of `path` exists, creating it (and any
48
    /// missing ancestors) if necessary.
49
    ///
50
    /// # Arguments
51
    ///
52
    /// * `path` - File path whose parent directory must exist.
53
    ///
54
    /// # Errors
55
    ///
56
    /// Returns an error if the directory cannot be created.
57
    fn ensure_parent_dir(&self, path: &Path) -> Result<()>;
58
59
    /// Verify that `docker` is installed on `PATH` and that its daemon is
60
    /// reachable. Called before any `docker run` invocation so the user
61
    /// gets a helpful message instead of a raw pipe/socket error.
62
    ///
63
    /// # Errors
64
    ///
65
    /// Returns an error describing whether the binary is missing or the
66
    /// daemon is not running.
67
    fn check_docker_ready(&self) -> Result<()>;
68
69
    /// Return `true` when `image` is already present in the local image
70
    /// cache (i.e. `docker image inspect <image>` succeeds).
71
    fn docker_image_exists(&self, image: &str) -> bool;
72
73
    /// Run `docker pull <image>` with inherited stdio so the user sees
74
    /// layer-download progress.
75
    ///
76
    /// # Errors
77
    ///
78
    /// Returns an error if `docker pull` exits with a non-zero status.
79
    fn docker_pull(&self, image: &str) -> Result<()>;
80
81
    /// Invoke `docker` with the given argument list and environment.
82
    ///
83
    /// # Arguments
84
    ///
85
    /// * `args` - Arguments passed to `docker` (starting with the
86
    ///   subcommand, e.g. `run`).
87
    /// * `envs` - Additional `(key, value)` environment variables applied
88
    ///   to the spawned `docker` process; these are forwarded to the
89
    ///   container via explicit `-e` flags built into `args`.
90
    ///
91
    /// # Errors
92
    ///
93
    /// Returns an error if the process cannot be started or exits with a
94
    /// non-zero status.
95
    fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()>;
96
97
    /// Print an informational message to stdout.
98
    fn print_info(&self, message: &str);
99
100
    /// Print a debug-level message. Intended for low-level command
101
    /// traces (e.g. the exact `docker` invocation) that would be noisy by
102
    /// default but useful when troubleshooting. The production
103
    /// implementation only emits the message when `CSSHW_XTASK_VERBOSE`
104
    /// is set to a non-empty value.
105
    fn print_debug(&self, message: &str);
106
}
107
108
/// Production implementation of [`SocialPreviewSystem`].
109
pub struct RealSystem;
110
111
#[cfg_attr(coverage_nightly, coverage(off))]
112
impl SocialPreviewSystem for RealSystem {
113
    fn workspace_root(&self) -> Result<PathBuf> {
114
        // CARGO_MANIFEST_DIR is set by Cargo when building this binary; it
115
        // points at xtask/, whose parent is the workspace root.
116
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
117
        let root = Path::new(manifest_dir)
118
            .parent()
119
            .context("failed to resolve workspace root from CARGO_MANIFEST_DIR")?
120
            .to_path_buf();
121
        Ok(root)
122
    }
123
124
    fn env_var(&self, key: &str) -> Option<String> {
125
        std::env::var(key).ok().filter(|v| !v.is_empty())
126
    }
127
128
    fn ensure_parent_dir(&self, path: &Path) -> Result<()> {
129
        if let Some(parent) = path.parent() {
130
            std::fs::create_dir_all(parent)
131
                .with_context(|| format!("failed to create directory {}", parent.display()))?;
132
        }
133
        Ok(())
134
    }
135
136
    fn check_docker_ready(&self) -> Result<()> {
137
        // `docker info` is cheap and exercises both the CLI resolution
138
        // path and a round-trip to the daemon socket.
139
        let output = match std::process::Command::new("docker")
140
            .args(["info", "--format", "{{.ServerVersion}}"])
141
            .output()
142
        {
143
            Ok(o) => o,
144
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
145
                bail!(
146
                    "`docker` was not found on PATH. Install Docker Desktop (or the Docker Engine) and ensure `docker` is on your PATH."
147
                );
148
            }
149
            Err(e) => {
150
                return Err(e).context("failed to spawn `docker info`");
151
            }
152
        };
153
        if output.status.success() && !output.stdout.is_empty() {
154
            return Ok(());
155
        }
156
        let stderr = String::from_utf8_lossy(&output.stderr);
157
        bail!(
158
            "Docker is installed but its daemon is not reachable. Start Docker Desktop (or your Docker daemon) and try again.\n  docker info stderr: {}",
159
            stderr.trim()
160
        );
161
    }
162
163
    fn docker_image_exists(&self, image: &str) -> bool {
164
        std::process::Command::new("docker")
165
            .args(["image", "inspect", image])
166
            .stdout(std::process::Stdio::null())
167
            .stderr(std::process::Stdio::null())
168
            .status()
169
            .map(|s| s.success())
170
            .unwrap_or(false)
171
    }
172
173
    fn docker_pull(&self, image: &str) -> Result<()> {
174
        let status = std::process::Command::new("docker")
175
            .args(["pull", image])
176
            .status()
177
            .with_context(|| format!("failed to spawn `docker pull {image}`"))?;
178
        if !status.success() {
179
            bail!("`docker pull {image}` failed with status {status}");
180
        }
181
        Ok(())
182
    }
183
184
    fn run_docker(&self, args: &[String], envs: &[(String, String)]) -> Result<()> {
185
        let mut command = std::process::Command::new("docker");
186
        command.args(args);
187
        for (key, value) in envs {
188
            command.env(key, value);
189
        }
190
        let status = command
191
            .status()
192
            .context("failed to spawn `docker`; is Docker installed and on PATH?")?;
193
        if !status.success() {
194
            bail!("`docker {}` failed with status {status}", args.join(" "));
195
        }
196
        Ok(())
197
    }
198
199
    fn print_info(&self, message: &str) {
200
        println!("INFO - {message}");
201
    }
202
203
    fn print_debug(&self, message: &str) {
204
        if std::env::var("CSSHW_XTASK_VERBOSE")
205
            .map(|v| !v.is_empty())
206
            .unwrap_or(false)
207
        {
208
            eprintln!("DEBUG - {message}");
209
        }
210
    }
211
}
212
213
/// Split the caller-supplied `--out` into (host-absolute path, workspace-
214
/// relative path with forward slashes).
215
///
216
/// Accepts any path. Relative paths resolve against the workspace root;
217
/// absolute paths are used as-is. Lexical `..` components are normalised
218
/// so inputs like `sub/../preview.png` are supported. The final resolved
219
/// path must still live under `workspace_root` so the container bind mount
220
/// can reach it at `/workspace/<rel>`; paths outside the workspace are
221
/// rejected with a clear error.
222
11
fn resolve_out_paths(workspace_root: &Path, out: Option<PathBuf>) -> Result<(PathBuf, String)> {
223
11
    let raw = out.unwrap_or_else(|| 
PathBuf::from7
(DEFAULT_OUT));
224
11
    let joined = if raw.is_absolute() {
225
1
        raw.clone()
226
    } else {
227
10
        workspace_root.join(&raw)
228
    };
229
11
    let normalised = normalise_path(&joined);
230
11
    let 
rel9
= normalised.strip_prefix(workspace_root).map_err(|_|
{2
231
2
        anyhow::anyhow!(
232
            "--out must resolve to a path inside the workspace root ({}); got {}",
233
2
            workspace_root.display(),
234
2
            raw.display()
235
        )
236
2
    })?;
237
9
    let rel_str = rel.to_string_lossy().replace('\\', "/");
238
9
    Ok((normalised.clone(), rel_str))
239
11
}
240
241
/// Lexically normalise a path by collapsing `.` and `..` components
242
/// without touching the filesystem. Behaves like `Path::canonicalize`
243
/// minus the requirement that the path exist. `..` at the root is
244
/// dropped (matching POSIX semantics).
245
11
fn normalise_path(path: &Path) -> PathBuf {
246
    use std::path::Component;
247
11
    let mut out = PathBuf::new();
248
45
    for comp in 
path11
.
components11
() {
249
45
        match comp {
250
            Component::ParentDir => {
251
                // Only pop if the last pushed component is a regular
252
                // segment; otherwise drop (root `..`) or keep (leading
253
                // `..` on a relative path).
254
2
                let popped = match out.components().next_back() {
255
                    Some(Component::Normal(_)) => {
256
2
                        out.pop();
257
2
                        true
258
                    }
259
0
                    _ => false,
260
                };
261
2
                if !popped && 
!path.is_absolute()0
{
262
0
                    out.push("..");
263
2
                }
264
            }
265
0
            Component::CurDir => {}
266
43
            other => out.push(other.as_os_str()),
267
        }
268
    }
269
11
    out
270
11
}
271
272
/// Render a `docker` argument list as a single shell-quoted string, purely
273
/// for diagnostic logging. Arguments containing whitespace or shell
274
/// metacharacters are wrapped in single quotes; inner single quotes are
275
/// escaped as `'\''`. This is never re-parsed — it's only printed to
276
/// stdout so a user can copy-paste the exact invocation.
277
8
fn shell_quote_args(args: &[String]) -> String {
278
8
    args.iter()
279
100
        .
map8
(|a| {
280
100
            if a.is_empty()
281
1.16k
                || 
a.chars()100
.
any100
(|c| {
282
1.16k
                    c.is_whitespace()
283
1.15k
                        || matches!(
284
1.16k
                            c,
285
                            '\'' | '"'
286
                                | '$'
287
                                | '`'
288
                                | '\\'
289
                                | '&'
290
                                | '|'
291
                                | ';'
292
                                | '<'
293
                                | '>'
294
                                | '('
295
                                | ')'
296
                                | '{'
297
                                | '}'
298
                                | '*'
299
                                | '?'
300
                                | '#'
301
                                | '!'
302
                                | '['
303
                                | ']'
304
                        )
305
1.16k
                })
306
            {
307
8
                format!("'{}'", a.replace('\'', "'\\''"))
308
            } else {
309
92
                a.clone()
310
            }
311
100
        })
312
8
        .collect::<Vec<_>>()
313
8
        .join(" ")
314
8
}
315
316
/// Build the `docker run` argument list for the generator script.
317
8
fn build_docker_args(workspace_root: &Path, container_out: &str, has_token: bool) -> Vec<String> {
318
8
    let mount = format!(
319
        "{}:{CONTAINER_WORKSPACE}",
320
8
        workspace_root.to_string_lossy().replace('\\', "/")
321
    );
322
8
    let mut args: Vec<String> = vec![
323
8
        "run".into(),
324
8
        "--rm".into(),
325
8
        "-v".into(),
326
8
        mount,
327
8
        "-w".into(),
328
8
        CONTAINER_WORKSPACE.into(),
329
8
        "-e".into(),
330
8
        format!("OUT_PATH={container_out}"),
331
    ];
332
8
    if has_token {
333
2
        args.push("-e".into());
334
2
        args.push("GITHUB_TOKEN".into());
335
6
    }
336
8
    args.push(PLAYWRIGHT_IMAGE.into());
337
8
    args.push("sh".into());
338
8
    args.push("-c".into());
339
    // Install node_modules on first run, then invoke the generator. We
340
    // use `npm ci` (not `npm install`) so the install is strictly driven
341
    // by the committed `package-lock.json`; this keeps runs reproducible
342
    // and prevents the bind-mounted workspace from picking up lockfile
343
    // mutations. Subsequent runs skip the install entirely and stay
344
    // offline.
345
    //
346
    // The install runs in a subshell so it does not alter the CWD of the
347
    // subsequent `node` invocation. `generate.mjs` resolves its inputs
348
    // (template, logo, font, linguist colors) as workspace-relative paths,
349
    // so it must run from `/workspace` — not from
350
    // `/workspace/xtask/social-preview`.
351
8
    args.push(
352
8
        "( cd xtask/social-preview && { [ -d node_modules ] || npm ci; } ) && node xtask/social-preview/generate.mjs"
353
8
            .into(),
354
    );
355
8
    args
356
8
}
357
358
/// Generate the social preview PNG.
359
///
360
/// Resolves the output path, ensures the host-side output directory
361
/// exists, and invokes the Playwright Docker container which runs
362
/// `xtask/social-preview/generate.mjs` to fetch live GitHub data and
363
/// render `templates/social-preview.html` to PNG.
364
///
365
/// # Arguments
366
///
367
/// * `system` - Injected I/O provider.
368
/// * `out` - Optional output path override. Relative paths resolve against
369
///   the workspace root.
370
/// * `token` - Optional GitHub token override. Falls back to the
371
///   `GITHUB_TOKEN` environment variable, then unauthenticated access.
372
///
373
/// # Errors
374
///
375
/// Returns an error if the workspace root cannot be resolved, the output
376
/// directory cannot be created, or the `docker run` invocation fails.
377
11
pub fn generate_social_preview<S: SocialPreviewSystem>(
378
11
    system: &S,
379
11
    out: Option<PathBuf>,
380
11
    token: Option<String>,
381
11
) -> Result<()> {
382
11
    let workspace_root = system.workspace_root()
?0
;
383
11
    let (
host_out9
,
relative_out9
) = resolve_out_paths(&workspace_root, out)
?2
;
384
9
    system.print_info(&format!(
385
9
        "Generating social preview → {}",
386
9
        host_out.display()
387
9
    ));
388
9
    system.check_docker_ready()
?1
;
389
8
    if !system.docker_image_exists(PLAYWRIGHT_IMAGE) {
390
1
        system.print_info(&format!(
391
1
            "Pulling Playwright image {PLAYWRIGHT_IMAGE} (first run only)"
392
1
        ));
393
1
        system.docker_pull(PLAYWRIGHT_IMAGE)
?0
;
394
7
    }
395
8
    system.ensure_parent_dir(&host_out)
?0
;
396
397
8
    let container_out = format!("{CONTAINER_WORKSPACE}/{relative_out}");
398
8
    let resolved_token = token.or_else(|| 
system7
.
env_var7
(
"GITHUB_TOKEN"7
));
399
8
    let has_token = resolved_token.is_some();
400
401
8
    let args = build_docker_args(&workspace_root, &container_out, has_token);
402
8
    let envs: Vec<(String, String)> = resolved_token
403
8
        .into_iter()
404
8
        .map(|t| (
"GITHUB_TOKEN"2
.
to_owned2
(),
t2
))
405
8
        .collect();
406
407
8
    system.print_info(&format!("Starting Playwright container {PLAYWRIGHT_IMAGE}"));
408
8
    system.print_debug(&format!("+ docker {}", shell_quote_args(&args)));
409
8
    system.run_docker(&args, &envs)
?1
;
410
7
    system.print_info(&format!("Wrote {}", host_out.display()));
411
7
    Ok(())
412
11
}
413
414
#[cfg(test)]
415
#[path = "tests/test_social_preview.rs"]
416
mod tests;